import { Table } from "./table/Table"
import { TableColumn } from "./table/TableColumn"
import { TableForeignKey } from "./table/TableForeignKey"
import { TableIndex } from "./table/TableIndex"
import { QueryRunner } from "../query-runner/QueryRunner"
import { ColumnMetadata } from "../metadata/ColumnMetadata"
import { EntityMetadata } from "../metadata/EntityMetadata"
import { DataSource } from "../data-source/DataSource"
import { SchemaBuilder } from "./SchemaBuilder"
import { SqlInMemory } from "../driver/SqlInMemory"
import { TableUtils } from "./util/TableUtils"
import { TableColumnOptions } from "./options/TableColumnOptions"
import { TableUnique } from "./table/TableUnique"
import { TableCheck } from "./table/TableCheck"
import { TableExclusion } from "./table/TableExclusion"
import { View } from "./view/View"
import { ViewUtils } from "./util/ViewUtils"
import { DriverUtils } from "../driver/DriverUtils"
import { PostgresQueryRunner } from "../driver/postgres/PostgresQueryRunner"

/**
 * Creates complete tables schemas in the database based on the entity metadatas.
 *
 * Steps how schema is being built:
 * 1. load list of all tables with complete column and keys information from the db
 * 2. drop all (old) foreign keys that exist in the table, but does not exist in the metadata
 * 3. create new tables that does not exist in the db, but exist in the metadata
 * 4. drop all columns exist (left old) in the db table, but does not exist in the metadata
 * 5. add columns from metadata which does not exist in the table
 * 6. update all exist columns which metadata has changed
 * 7. update primary keys - update old and create new primary key from changed columns
 * 8. create foreign keys which does not exist in the table yet
 * 9. create indices which are missing in db yet, and drops indices which exist in the db, but does not exist in the metadata anymore
 */
export class RdbmsSchemaBuilder implements SchemaBuilder {
    readonly "@instanceof" = Symbol.for("RdbmsSchemaBuilder")

    /**
     * Used to execute schema creation queries in a single connection.
     */
    protected queryRunner: QueryRunner

    private currentDatabase?: string

    private currentSchema?: string

    // -------------------------------------------------------------------------
    // Constructor
    // -------------------------------------------------------------------------

    constructor(protected connection: DataSource) {}

    // -------------------------------------------------------------------------
    // Public Methods
    // -------------------------------------------------------------------------

    /**
     * Creates complete schemas for the given entity metadatas.
     */
    async build(): Promise<void> {
        this.queryRunner = this.connection.createQueryRunner()

        // this.connection.driver.database || this.currentDatabase;
        this.currentDatabase = this.connection.driver.database
        this.currentSchema = this.connection.driver.schema

        // CockroachDB implements asynchronous schema sync operations which can not been executed in transaction.
        // E.g. if you try to DROP column and ADD it again in the same transaction, crdb throws error.
        // In Spanner queries against the INFORMATION_SCHEMA can be used in a read-only transaction,
        // but not in a read-write transaction.
        const isUsingTransactions =
            !(this.connection.driver.options.type === "cockroachdb") &&
            !(this.connection.driver.options.type === "spanner") &&
            this.connection.options.migrationsTransactionMode !== "none"

        await this.queryRunner.beforeMigration()

        if (isUsingTransactions) {
            await this.queryRunner.startTransaction()
        }

        try {
            await this.createMetadataTableIfNecessary(this.queryRunner)
            // Flush the queryrunner table & view cache
            const tablePaths = this.entityToSyncMetadatas.map((metadata) =>
                this.getTablePath(metadata),
            )
            const viewPaths = this.viewEntityToSyncMetadatas.map((metadata) =>
                this.getTablePath(metadata),
            )

            await this.queryRunner.getTables(tablePaths)
            await this.queryRunner.getViews(viewPaths)

            await this.executeSchemaSyncOperationsInProperOrder()

            // if cache is enabled then perform cache-synchronization as well
            if (this.connection.queryResultCache)
                await this.connection.queryResultCache.synchronize(
                    this.queryRunner,
                )

            if (isUsingTransactions) {
                await this.queryRunner.commitTransaction()
            }
        } catch (error) {
            try {
                // we throw original error even if rollback thrown an error
                if (isUsingTransactions) {
                    await this.queryRunner.rollbackTransaction()
                }
            } catch (rollbackError) {}
            throw error
        } finally {
            await this.queryRunner.afterMigration()

            await this.queryRunner.release()
        }
    }

    /**
     * Create the typeorm_metadata table if necessary.
     */
    async createMetadataTableIfNecessary(
        queryRunner: QueryRunner,
    ): Promise<void> {
        if (
            this.viewEntityToSyncMetadatas.length > 0 ||
            this.hasGeneratedColumns()
        ) {
            await this.createTypeormMetadataTable(queryRunner)
        }
    }

    /**
     * Returns sql queries to be executed by schema builder.
     */
    async log(): Promise<SqlInMemory> {
        this.queryRunner = this.connection.createQueryRunner()
        try {
            // Flush the queryrunner table & view cache
            const tablePaths = this.entityToSyncMetadatas.map((metadata) =>
                this.getTablePath(metadata),
            )
            const viewPaths = this.viewEntityToSyncMetadatas.map((metadata) =>
                this.getTablePath(metadata),
            )
            await this.queryRunner.getTables(tablePaths)
            await this.queryRunner.getViews(viewPaths)

            this.queryRunner.enableSqlMemory()
            await this.executeSchemaSyncOperationsInProperOrder()

            // if cache is enabled then perform cache-synchronization as well
            if (this.connection.queryResultCache)
                // todo: check this functionality
                await this.connection.queryResultCache.synchronize(
                    this.queryRunner,
                )

            return this.queryRunner.getMemorySql()
        } finally {
            // its important to disable this mode despite the fact we are release query builder
            // because there exist drivers which reuse same query runner. Also its important to disable
            // sql memory after call of getMemorySql() method because last one flushes sql memory.
            this.queryRunner.disableSqlMemory()
            await this.queryRunner.release()
        }
    }

    // -------------------------------------------------------------------------
    // Protected Methods
    // -------------------------------------------------------------------------

    /**
     * Returns only entities that should be synced in the database.
     */
    protected get entityToSyncMetadatas(): EntityMetadata[] {
        return this.connection.entityMetadatas.filter(
            (metadata) =>
                metadata.synchronize &&
                metadata.tableType !== "entity-child" &&
                metadata.tableType !== "view",
        )
    }

    /**
     * Returns only entities that should be synced in the database.
     */
    protected get viewEntityToSyncMetadatas(): EntityMetadata[] {
        return (
            this.connection.entityMetadatas
                .filter(
                    (metadata) =>
                        metadata.tableType === "view" && metadata.synchronize,
                )
                // sort views in creation order by dependencies
                .sort(ViewUtils.viewMetadataCmp)
        )
    }

    /**
     * Checks if there are at least one generated column.
     */
    protected hasGeneratedColumns(): boolean {
        return this.connection.entityMetadatas.some((entityMetadata) => {
            return entityMetadata.columns.some((column) => column.generatedType)
        })
    }

    /**
     * Executes schema sync operations in a proper order.
     * Order of operations matter here.
     */
    protected async executeSchemaSyncOperationsInProperOrder(): Promise<void> {
        await this.dropOldViews()
        await this.dropOldForeignKeys()
        await this.dropOldIndices()
        await this.dropOldChecks()
        await this.dropOldExclusions()
        await this.dropCompositeUniqueConstraints()
        // await this.renameTables();
        await this.renameColumns()
        await this.changeTableComment()
        await this.createNewTables()
        await this.dropRemovedColumns()
        await this.addNewColumns()
        await this.updatePrimaryKeys()
        await this.updateExistColumns()
        await this.createNewIndices()
        await this.createNewChecks()
        await this.createNewExclusions()
        await this.createCompositeUniqueConstraints()
        await this.createForeignKeys()
        await this.createViews()
        await this.createNewViewIndices()
    }

    private getTablePath(
        target: EntityMetadata | Table | View | TableForeignKey | string,
    ): string {
        const parsed = this.connection.driver.parseTableName(target)

        return this.connection.driver.buildTableName(
            parsed.tableName,
            parsed.schema || this.currentSchema,
            parsed.database || this.currentDatabase,
        )
    }

    /**
     * Drops all (old) foreign keys that exist in the tables, but do not exist in the entity metadata.
     */
    protected async dropOldForeignKeys(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            // find foreign keys that exist in the schemas but does not exist in the entity metadata
            const tableForeignKeysToDrop = table.foreignKeys.filter(
                (tableForeignKey) => {
                    const metadataFK = metadata.foreignKeys.find(
                        (metadataForeignKey) =>
                            tableForeignKey.name === metadataForeignKey.name &&
                            this.getTablePath(tableForeignKey) ===
                                this.getTablePath(
                                    metadataForeignKey.referencedEntityMetadata,
                                ),
                    )
                    return (
                        !metadataFK ||
                        (metadataFK.onDelete &&
                            metadataFK.onDelete !== tableForeignKey.onDelete) ||
                        (metadataFK.onUpdate &&
                            metadataFK.onUpdate !== tableForeignKey.onUpdate)
                    )
                },
            )
            if (tableForeignKeysToDrop.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `dropping old foreign keys of ${
                    table.name
                }: ${tableForeignKeysToDrop
                    .map((dbForeignKey) => dbForeignKey.name)
                    .join(", ")}`,
            )

            // drop foreign keys from the database
            await this.queryRunner.dropForeignKeys(
                table,
                tableForeignKeysToDrop,
            )
        }
    }

    /**
     * Rename tables
     */
    protected async renameTables(): Promise<void> {
        // for (const metadata of this.entityToSyncMetadatas) {
        //     const table = this.queryRunner.loadedTables.find(table => this.getTablePath(table) === this.getTablePath(metadata));
        // }
    }

    /**
     * Renames columns.
     * Works if only one column per table was changed.
     * Changes only column name. If something besides name was changed, these changes will be ignored.
     */
    protected async renameColumns(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            if (metadata.columns.length !== table.columns.length) continue

            const renamedMetadataColumns = metadata.columns
                .filter((c) => !c.isVirtualProperty)
                .filter((column) => {
                    return !table.columns.find((tableColumn) => {
                        return (
                            tableColumn.name === column.databaseName &&
                            tableColumn.type ===
                                this.connection.driver.normalizeType(column) &&
                            tableColumn.isNullable === column.isNullable &&
                            tableColumn.isUnique ===
                                this.connection.driver.normalizeIsUnique(column)
                        )
                    })
                })

            if (
                renamedMetadataColumns.length === 0 ||
                renamedMetadataColumns.length > 1
            )
                continue

            const renamedTableColumns = table.columns.filter((tableColumn) => {
                return !metadata.columns.find((column) => {
                    return (
                        !column.isVirtualProperty &&
                        column.databaseName === tableColumn.name &&
                        this.connection.driver.normalizeType(column) ===
                            tableColumn.type &&
                        column.isNullable === tableColumn.isNullable &&
                        this.connection.driver.normalizeIsUnique(column) ===
                            tableColumn.isUnique
                    )
                })
            })

            if (
                renamedTableColumns.length === 0 ||
                renamedTableColumns.length > 1
            )
                continue

            const renamedColumn = renamedTableColumns[0].clone()
            renamedColumn.name = renamedMetadataColumns[0].databaseName

            this.connection.logger.logSchemaBuild(
                `renaming column "${renamedTableColumns[0].name}" in "${table.name}" to "${renamedColumn.name}"`,
            )
            await this.queryRunner.renameColumn(
                table,
                renamedTableColumns[0],
                renamedColumn,
            )
        }
    }

    protected async dropOldIndices(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const dropQueries = table.indices
                .filter((tableIndex) => {
                    const indexMetadata = metadata.indices.find(
                        (index) => index.name === tableIndex.name,
                    )
                    if (indexMetadata) {
                        if (indexMetadata.synchronize === false) return false

                        if (indexMetadata.isUnique !== tableIndex.isUnique)
                            return true

                        if (indexMetadata.isSpatial !== tableIndex.isSpatial)
                            return true

                        if (
                            this.connection.driver.isFullTextColumnTypeSupported() &&
                            indexMetadata.isFulltext !== tableIndex.isFulltext
                        )
                            return true

                        if (
                            indexMetadata.columns.length !==
                            tableIndex.columnNames.length
                        )
                            return true

                        return !indexMetadata.columns.every(
                            (column) =>
                                tableIndex.columnNames.indexOf(
                                    column.databaseName,
                                ) !== -1,
                        )
                    }

                    return true
                })
                .map(async (tableIndex) => {
                    this.connection.logger.logSchemaBuild(
                        `dropping an index: "${tableIndex.name}" from table ${table.name}`,
                    )
                    await this.queryRunner.dropIndex(table, tableIndex)
                })

            await Promise.all(dropQueries)
        }
        if (this.connection.options.type === "postgres") {
            const postgresQueryRunner: PostgresQueryRunner = <
                PostgresQueryRunner
            >this.queryRunner
            for (const metadata of this.viewEntityToSyncMetadatas) {
                const view = this.queryRunner.loadedViews.find(
                    (view) =>
                        this.getTablePath(view) === this.getTablePath(metadata),
                )
                if (!view) continue

                const dropQueries = view.indices
                    .filter((tableIndex) => {
                        const indexMetadata = metadata.indices.find(
                            (index) => index.name === tableIndex.name,
                        )
                        if (indexMetadata) {
                            if (indexMetadata.synchronize === false)
                                return false

                            if (indexMetadata.isUnique !== tableIndex.isUnique)
                                return true

                            if (
                                indexMetadata.isSpatial !== tableIndex.isSpatial
                            )
                                return true

                            if (
                                this.connection.driver.isFullTextColumnTypeSupported() &&
                                indexMetadata.isFulltext !==
                                    tableIndex.isFulltext
                            )
                                return true

                            if (
                                indexMetadata.columns.length !==
                                tableIndex.columnNames.length
                            )
                                return true

                            return !indexMetadata.columns.every(
                                (column) =>
                                    tableIndex.columnNames.indexOf(
                                        column.databaseName,
                                    ) !== -1,
                            )
                        }

                        return true
                    })
                    .map(async (tableIndex) => {
                        this.connection.logger.logSchemaBuild(
                            `dropping an index: "${tableIndex.name}" from view ${view.name}`,
                        )
                        await postgresQueryRunner.dropViewIndex(
                            view,
                            tableIndex,
                        )
                    })

                await Promise.all(dropQueries)
            }
        }
    }

    protected async dropOldChecks(): Promise<void> {
        // Mysql does not support check constraints
        if (
            DriverUtils.isMySQLFamily(this.connection.driver) ||
            this.connection.driver.options.type === "aurora-mysql"
        )
            return

        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const oldChecks = table.checks.filter((tableCheck) => {
                return !metadata.checks.find(
                    (checkMetadata) => checkMetadata.name === tableCheck.name,
                )
            })

            if (oldChecks.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `dropping old check constraint: ${oldChecks
                    .map((check) => `"${check.name}"`)
                    .join(", ")} from table "${table.name}"`,
            )
            await this.queryRunner.dropCheckConstraints(table, oldChecks)
        }
    }

    protected async dropCompositeUniqueConstraints(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const compositeUniques = table.uniques.filter((tableUnique) => {
                return (
                    tableUnique.columnNames.length > 1 &&
                    !metadata.uniques.find(
                        (uniqueMetadata) =>
                            uniqueMetadata.name === tableUnique.name,
                    )
                )
            })

            if (compositeUniques.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `dropping old unique constraint: ${compositeUniques
                    .map((unique) => `"${unique.name}"`)
                    .join(", ")} from table "${table.name}"`,
            )
            await this.queryRunner.dropUniqueConstraints(
                table,
                compositeUniques,
            )
        }
    }

    protected async dropOldExclusions(): Promise<void> {
        // Only PostgreSQL supports exclusion constraints
        if (!(this.connection.driver.options.type === "postgres")) return

        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const oldExclusions = table.exclusions.filter((tableExclusion) => {
                return !metadata.exclusions.find(
                    (exclusionMetadata) =>
                        exclusionMetadata.name === tableExclusion.name,
                )
            })

            if (oldExclusions.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `dropping old exclusion constraint: ${oldExclusions
                    .map((exclusion) => `"${exclusion.name}"`)
                    .join(", ")} from table "${table.name}"`,
            )
            await this.queryRunner.dropExclusionConstraints(
                table,
                oldExclusions,
            )
        }
    }

    /**
     * change table comment
     */
    protected async changeTableComment(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            if (
                DriverUtils.isMySQLFamily(this.connection.driver) ||
                this.connection.driver.options.type === 'postgres'
            ) {
                const newComment = metadata.comment
                await this.queryRunner.changeTableComment(table, newComment)
            }
        }
    }

    /**
     * Creates tables that do not exist in the database yet.
     * New tables are created without foreign and primary keys.
     * Primary key only can be created in conclusion with auto generated column.
     */
    protected async createNewTables(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            // check if table does not exist yet
            const existTable = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (existTable) continue

            this.connection.logger.logSchemaBuild(
                `creating a new table: ${this.getTablePath(metadata)}`,
            )

            // create a new table and sync it in the database
            const table = Table.create(metadata, this.connection.driver)
            await this.queryRunner.createTable(table, false, false)
            this.queryRunner.loadedTables.push(table)
        }
    }

    protected async createViews(): Promise<void> {
        for (const metadata of this.viewEntityToSyncMetadatas) {
            // check if view does not exist yet
            const existView = this.queryRunner.loadedViews.find((view) => {
                const viewExpression =
                    typeof view.expression === "string"
                        ? view.expression.trim()
                        : view.expression(this.connection).getQuery()
                const metadataExpression =
                    typeof metadata.expression === "string"
                        ? metadata.expression.trim()
                        : metadata.expression!(this.connection).getQuery()
                return (
                    this.getTablePath(view) === this.getTablePath(metadata) &&
                    viewExpression === metadataExpression
                )
            })
            if (existView) continue

            this.connection.logger.logSchemaBuild(
                `creating a new view: ${this.getTablePath(metadata)}`,
            )

            // create a new view and sync it in the database
            const view = View.create(metadata, this.connection.driver)
            await this.queryRunner.createView(view, true)
            this.queryRunner.loadedViews.push(view)
        }
    }

    protected async dropOldViews(): Promise<void> {
        const droppedViews: Array<View> = []
        const viewEntityToSyncMetadatas = this.viewEntityToSyncMetadatas
        // BuIld lookup cache for finding views metadata
        const viewToMetadata = new Map<View, EntityMetadata>()
        for (const view of this.queryRunner.loadedViews) {
            const viewMetadata = viewEntityToSyncMetadatas.find((metadata) => {
                return this.getTablePath(view) === this.getTablePath(metadata)
            })
            if (viewMetadata) {
                viewToMetadata.set(view, viewMetadata)
            }
        }
        // Gather all changed view, that need a drop
        for (const view of this.queryRunner.loadedViews) {
            const viewMetadata = viewToMetadata.get(view)
            if (!viewMetadata) {
                continue
            }
            const viewExpression =
                typeof view.expression === "string"
                    ? view.expression.trim()
                    : view.expression(this.connection).getQuery()
            const metadataExpression =
                typeof viewMetadata.expression === "string"
                    ? viewMetadata.expression.trim()
                    : viewMetadata.expression!(this.connection).getQuery()

            if (viewExpression === metadataExpression) continue

            this.connection.logger.logSchemaBuild(
                `dropping an old view: ${view.name}`,
            )

            // Collect view to be dropped
            droppedViews.push(view)
        }

        // Helper function that for a given view, will recursively return list of the view and all views that depend on it
        const viewDependencyChain = (view: View): View[] => {
            // Get the view metadata
            const viewMetadata = viewToMetadata.get(view)
            let viewWithDependencies = [view]
            // If no metadata is known for the view, simply return the view itself
            if (!viewMetadata) {
                return viewWithDependencies
            }
            // Iterate over all known views
            for (const [
                currentView,
                currentMetadata,
            ] of viewToMetadata.entries()) {
                // Ignore self reference
                if (currentView === view) {
                    continue
                }
                // If the currently iterated view depends on the passed in view
                if (
                    currentMetadata.dependsOn &&
                    (currentMetadata.dependsOn.has(viewMetadata.target) ||
                        currentMetadata.dependsOn.has(viewMetadata.name))
                ) {
                    // Recursively add currently iterate view and its dependents
                    viewWithDependencies = viewWithDependencies.concat(
                        viewDependencyChain(currentView),
                    )
                }
            }
            // Return all collected views
            return viewWithDependencies
        }

        // Collect final list of views to be dropped in a Set so there are no duplicates
        const droppedViewsWithDependencies: Set<View> = new Set(
            // Collect all dropped views, and their dependencies
            droppedViews
                .map((view) => viewDependencyChain(view))
                // Flattened to single Array ( can be replaced with flatMap, once supported)
                .reduce((all, segment) => {
                    return all.concat(segment)
                }, [])
                // Sort the views to be dropped in creation order
                .sort((a, b) => {
                    return ViewUtils.viewMetadataCmp(
                        viewToMetadata.get(a),
                        viewToMetadata.get(b),
                    )
                })
                // reverse order to get drop order
                .reverse(),
        )

        // Finally emit all drop views
        for (const view of droppedViewsWithDependencies) {
            await this.queryRunner.dropView(view)
        }
        this.queryRunner.loadedViews = this.queryRunner.loadedViews.filter(
            (view) => !droppedViewsWithDependencies.has(view),
        )
    }

    /**
     * Drops all columns that exist in the table, but does not exist in the metadata (left old).
     * We drop their keys too, since it should be safe.
     */
    protected async dropRemovedColumns(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            // find columns that exist in the database but does not exist in the metadata
            const droppedTableColumns = table.columns.filter((tableColumn) => {
                return !metadata.columns.find(
                    (columnMetadata) =>
                        columnMetadata.isVirtualProperty ||
                        columnMetadata.databaseName === tableColumn.name,
                )
            })
            if (droppedTableColumns.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `columns dropped in ${table.name}: ` +
                    droppedTableColumns.map((column) => column.name).join(", "),
            )

            // drop columns from the database
            await this.queryRunner.dropColumns(table, droppedTableColumns)
        }
    }

    /**
     * Adds columns from metadata which does not exist in the table.
     * Columns are created without keys.
     */
    protected async addNewColumns(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            // find which columns are new
            const newColumnMetadatas = metadata.columns.filter(
                (columnMetadata) => {
                    return (
                        !columnMetadata.isVirtualProperty &&
                        !table.columns.find(
                            (tableColumn) =>
                                tableColumn.name ===
                                columnMetadata.databaseName,
                        )
                    )
                },
            )
            if (newColumnMetadatas.length === 0) continue

            // create columns in the database
            const newTableColumnOptions =
                this.metadataColumnsToTableColumnOptions(newColumnMetadatas)
            const newTableColumns = newTableColumnOptions.map(
                (option) => new TableColumn(option),
            )

            if (newTableColumns.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `new columns added: ` +
                    newColumnMetadatas
                        .map((column) => column.databaseName)
                        .join(", "),
            )
            await this.queryRunner.addColumns(table, newTableColumns)
        }
    }

    /**
     * Updates composite primary keys.
     */
    protected async updatePrimaryKeys(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const primaryMetadataColumns = metadata.columns.filter(
                (column) => column.isPrimary,
            )
            const primaryTableColumns = table.columns.filter(
                (column) => column.isPrimary,
            )
            if (
                primaryTableColumns.length !== primaryMetadataColumns.length &&
                primaryMetadataColumns.length > 1
            ) {
                const changedPrimaryColumns = primaryMetadataColumns.map(
                    (primaryMetadataColumn) => {
                        return new TableColumn(
                            TableUtils.createTableColumnOptions(
                                primaryMetadataColumn,
                                this.connection.driver,
                            ),
                        )
                    },
                )
                await this.queryRunner.updatePrimaryKeys(
                    table,
                    changedPrimaryColumns,
                )
            }
        }
    }

    /**
     * Update all exist columns which metadata has changed.
     * Still don't create keys. Also we don't touch foreign keys of the changed columns.
     */
    protected async updateExistColumns(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const changedColumns = this.connection.driver.findChangedColumns(
                table.columns,
                metadata.columns,
            )
            if (changedColumns.length === 0) continue

            // drop all foreign keys that point to this column
            for (const changedColumn of changedColumns) {
                await this.dropColumnReferencedForeignKeys(
                    this.getTablePath(metadata),
                    changedColumn.databaseName,
                )
            }

            // drop all composite indices related to this column
            for (const changedColumn of changedColumns) {
                await this.dropColumnCompositeIndices(
                    this.getTablePath(metadata),
                    changedColumn.databaseName,
                )
            }

            // drop all composite uniques related to this column
            // Mysql does not support unique constraints.
            if (
                !(
                    DriverUtils.isMySQLFamily(this.connection.driver) ||
                    this.connection.driver.options.type === "aurora-mysql" ||
                    this.connection.driver.options.type === "spanner"
                )
            ) {
                for (const changedColumn of changedColumns) {
                    await this.dropColumnCompositeUniques(
                        this.getTablePath(metadata),
                        changedColumn.databaseName,
                    )
                }
            }

            // generate a map of new/old columns
            const newAndOldTableColumns = changedColumns.map(
                (changedColumn) => {
                    const oldTableColumn = table.columns.find(
                        (column) => column.name === changedColumn.databaseName,
                    )!
                    const newTableColumnOptions =
                        TableUtils.createTableColumnOptions(
                            changedColumn,
                            this.connection.driver,
                        )
                    const newTableColumn = new TableColumn(
                        newTableColumnOptions,
                    )

                    return {
                        oldColumn: oldTableColumn,
                        newColumn: newTableColumn,
                    }
                },
            )

            if (newAndOldTableColumns.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `columns changed in "${table.name}". updating: ` +
                    changedColumns
                        .map((column) => column.databaseName)
                        .join(", "),
            )
            await this.queryRunner.changeColumns(table, newAndOldTableColumns)
        }
    }

    /**
     * Creates composite indices which are missing in db yet.
     */
    protected async createNewIndices(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const newIndices = metadata.indices
                .filter(
                    (indexMetadata) =>
                        !table.indices.find(
                            (tableIndex) =>
                                tableIndex.name === indexMetadata.name,
                        ) && indexMetadata.synchronize === true,
                )
                .map((indexMetadata) => TableIndex.create(indexMetadata))

            if (newIndices.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `adding new indices ${newIndices
                    .map((index) => `"${index.name}"`)
                    .join(", ")} in table "${table.name}"`,
            )
            await this.queryRunner.createIndices(table, newIndices)
        }
    }

    /**
     * Creates indices for materialized views.
     */
    protected async createNewViewIndices(): Promise<void> {
        // Only PostgreSQL supports indices for materialized views.
        if (
            this.connection.options.type !== "postgres" ||
            !DriverUtils.isPostgresFamily(this.connection.driver)
        ) {
            return
        }
        const postgresQueryRunner: PostgresQueryRunner = <PostgresQueryRunner>(
            this.queryRunner
        )
        for (const metadata of this.viewEntityToSyncMetadatas) {
            // check if view does not exist yet
            const view = this.queryRunner.loadedViews.find((view) => {
                const viewExpression =
                    typeof view.expression === "string"
                        ? view.expression.trim()
                        : view.expression(this.connection).getQuery()
                const metadataExpression =
                    typeof metadata.expression === "string"
                        ? metadata.expression.trim()
                        : metadata.expression!(this.connection).getQuery()
                return (
                    this.getTablePath(view) === this.getTablePath(metadata) &&
                    viewExpression === metadataExpression
                )
            })
            if (!view || !view.materialized) continue

            const newIndices = metadata.indices
                .filter(
                    (indexMetadata) =>
                        !view.indices.find(
                            (tableIndex) =>
                                tableIndex.name === indexMetadata.name,
                        ) && indexMetadata.synchronize === true,
                )
                .map((indexMetadata) => TableIndex.create(indexMetadata))

            if (newIndices.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `adding new indices ${newIndices
                    .map((index) => `"${index.name}"`)
                    .join(", ")} in view "${view.name}"`,
            )
            await postgresQueryRunner.createViewIndices(view, newIndices)
        }
    }

    protected async createNewChecks(): Promise<void> {
        // Mysql does not support check constraints
        if (
            DriverUtils.isMySQLFamily(this.connection.driver) ||
            this.connection.driver.options.type === "aurora-mysql"
        )
            return

        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const newChecks = metadata.checks
                .filter(
                    (checkMetadata) =>
                        !table.checks.find(
                            (tableCheck) =>
                                tableCheck.name === checkMetadata.name,
                        ),
                )
                .map((checkMetadata) => TableCheck.create(checkMetadata))

            if (newChecks.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `adding new check constraints: ${newChecks
                    .map((index) => `"${index.name}"`)
                    .join(", ")} in table "${table.name}"`,
            )
            await this.queryRunner.createCheckConstraints(table, newChecks)
        }
    }

    /**
     * Creates composite uniques which are missing in db yet.
     */
    protected async createCompositeUniqueConstraints(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const compositeUniques = metadata.uniques
                .filter(
                    (uniqueMetadata) =>
                        uniqueMetadata.columns.length > 1 &&
                        !table.uniques.find(
                            (tableUnique) =>
                                tableUnique.name === uniqueMetadata.name,
                        ),
                )
                .map((uniqueMetadata) => TableUnique.create(uniqueMetadata))

            if (compositeUniques.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `adding new unique constraints: ${compositeUniques
                    .map((unique) => `"${unique.name}"`)
                    .join(", ")} in table "${table.name}"`,
            )
            await this.queryRunner.createUniqueConstraints(
                table,
                compositeUniques,
            )
        }
    }

    /**
     * Creates exclusions which are missing in db yet.
     */
    protected async createNewExclusions(): Promise<void> {
        // Only PostgreSQL supports exclusion constraints
        if (!(this.connection.driver.options.type === "postgres")) return

        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const newExclusions = metadata.exclusions
                .filter(
                    (exclusionMetadata) =>
                        !table.exclusions.find(
                            (tableExclusion) =>
                                tableExclusion.name === exclusionMetadata.name,
                        ),
                )
                .map((exclusionMetadata) =>
                    TableExclusion.create(exclusionMetadata),
                )

            if (newExclusions.length === 0) continue

            this.connection.logger.logSchemaBuild(
                `adding new exclusion constraints: ${newExclusions
                    .map((exclusion) => `"${exclusion.name}"`)
                    .join(", ")} in table "${table.name}"`,
            )
            await this.queryRunner.createExclusionConstraints(
                table,
                newExclusions,
            )
        }
    }

    /**
     * Creates foreign keys which does not exist in the table yet.
     */
    protected async createForeignKeys(): Promise<void> {
        for (const metadata of this.entityToSyncMetadatas) {
            const table = this.queryRunner.loadedTables.find(
                (table) =>
                    this.getTablePath(table) === this.getTablePath(metadata),
            )
            if (!table) continue

            const newKeys = metadata.foreignKeys.filter((foreignKey) => {
                return !table.foreignKeys.find(
                    (dbForeignKey) =>
                        dbForeignKey.name === foreignKey.name &&
                        this.getTablePath(dbForeignKey) ===
                            this.getTablePath(
                                foreignKey.referencedEntityMetadata,
                            ),
                )
            })
            if (newKeys.length === 0) continue

            const dbForeignKeys = newKeys.map((foreignKeyMetadata) =>
                TableForeignKey.create(
                    foreignKeyMetadata,
                    this.connection.driver,
                ),
            )
            this.connection.logger.logSchemaBuild(
                `creating a foreign keys: ${newKeys
                    .map((key) => key.name)
                    .join(", ")} on table "${table.name}"`,
            )
            await this.queryRunner.createForeignKeys(table, dbForeignKeys)
        }
    }

    /**
     * Drops all foreign keys where given column of the given table is being used.
     */
    protected async dropColumnReferencedForeignKeys(
        tablePath: string,
        columnName: string,
    ): Promise<void> {
        const table = this.queryRunner.loadedTables.find(
            (table) => this.getTablePath(table) === tablePath,
        )
        if (!table) return

        const tablesWithFK: Table[] = []
        const columnForeignKey = table.foreignKeys.find(
            (foreignKey) => foreignKey.columnNames.indexOf(columnName) !== -1,
        )
        if (columnForeignKey) {
            const clonedTable = table.clone()
            clonedTable.foreignKeys = [columnForeignKey]
            tablesWithFK.push(clonedTable)
            table.removeForeignKey(columnForeignKey)
        }

        for (const loadedTable of this.queryRunner.loadedTables) {
            const dependForeignKeys = loadedTable.foreignKeys.filter(
                (foreignKey) => {
                    return (
                        this.getTablePath(foreignKey) === tablePath &&
                        foreignKey.referencedColumnNames.indexOf(columnName) !==
                            -1
                    )
                },
            )

            if (dependForeignKeys.length > 0) {
                const clonedTable = loadedTable.clone()
                clonedTable.foreignKeys = dependForeignKeys
                tablesWithFK.push(clonedTable)
                dependForeignKeys.forEach((dependForeignKey) =>
                    loadedTable.removeForeignKey(dependForeignKey),
                )
            }
        }

        if (tablesWithFK.length > 0) {
            for (const tableWithFK of tablesWithFK) {
                this.connection.logger.logSchemaBuild(
                    `dropping related foreign keys of ${
                        tableWithFK.name
                    }: ${tableWithFK.foreignKeys
                        .map((foreignKey) => foreignKey.name)
                        .join(", ")}`,
                )
                await this.queryRunner.dropForeignKeys(
                    tableWithFK,
                    tableWithFK.foreignKeys,
                )
            }
        }
    }

    /**
     * Drops all composite indices, related to given column.
     */
    protected async dropColumnCompositeIndices(
        tablePath: string,
        columnName: string,
    ): Promise<void> {
        const table = this.queryRunner.loadedTables.find(
            (table) => this.getTablePath(table) === tablePath,
        )
        if (!table) return

        const relatedIndices = table.indices.filter(
            (index) =>
                index.columnNames.length > 1 &&
                index.columnNames.indexOf(columnName) !== -1,
        )
        if (relatedIndices.length === 0) return

        this.connection.logger.logSchemaBuild(
            `dropping related indices of "${tablePath}"."${columnName}": ${relatedIndices
                .map((index) => index.name)
                .join(", ")}`,
        )
        await this.queryRunner.dropIndices(table, relatedIndices)
    }

    /**
     * Drops all composite uniques, related to given column.
     */
    protected async dropColumnCompositeUniques(
        tablePath: string,
        columnName: string,
    ): Promise<void> {
        const table = this.queryRunner.loadedTables.find(
            (table) => this.getTablePath(table) === tablePath,
        )
        if (!table) return

        const relatedUniques = table.uniques.filter(
            (unique) =>
                unique.columnNames.length > 1 &&
                unique.columnNames.indexOf(columnName) !== -1,
        )
        if (relatedUniques.length === 0) return

        this.connection.logger.logSchemaBuild(
            `dropping related unique constraints of "${tablePath}"."${columnName}": ${relatedUniques
                .map((unique) => unique.name)
                .join(", ")}`,
        )
        await this.queryRunner.dropUniqueConstraints(table, relatedUniques)
    }

    /**
     * Creates new columns from the given column metadatas.
     */
    protected metadataColumnsToTableColumnOptions(
        columns: ColumnMetadata[],
    ): TableColumnOptions[] {
        return columns.map((columnMetadata) =>
            TableUtils.createTableColumnOptions(
                columnMetadata,
                this.connection.driver,
            ),
        )
    }

    /**
     * Creates typeorm service table for storing user defined Views and generate columns.
     */
    protected async createTypeormMetadataTable(queryRunner: QueryRunner) {
        const schema = this.currentSchema
        const database = this.currentDatabase
        const typeormMetadataTable = this.connection.driver.buildTableName(
            this.connection.metadataTableName,
            schema,
            database,
        )

        // Spanner requires at least one primary key in a table.
        // Since we don't have unique column in "typeorm_metadata" table
        // and we should avoid breaking changes, we mark all columns as primary for Spanner driver.
        const isPrimary = this.connection.driver.options.type === "spanner"
        await queryRunner.createTable(
            new Table({
                database: database,
                schema: schema,
                name: typeormMetadataTable,
                columns: [
                    {
                        name: "type",
                        type: this.connection.driver.normalizeType({
                            type: this.connection.driver.mappedDataTypes
                                .metadataType,
                        }),
                        isNullable: false,
                        isPrimary,
                    },
                    {
                        name: "database",
                        type: this.connection.driver.normalizeType({
                            type: this.connection.driver.mappedDataTypes
                                .metadataDatabase,
                        }),
                        isNullable: true,
                        isPrimary,
                    },
                    {
                        name: "schema",
                        type: this.connection.driver.normalizeType({
                            type: this.connection.driver.mappedDataTypes
                                .metadataSchema,
                        }),
                        isNullable: true,
                        isPrimary,
                    },
                    {
                        name: "table",
                        type: this.connection.driver.normalizeType({
                            type: this.connection.driver.mappedDataTypes
                                .metadataTable,
                        }),
                        isNullable: true,
                        isPrimary,
                    },
                    {
                        name: "name",
                        type: this.connection.driver.normalizeType({
                            type: this.connection.driver.mappedDataTypes
                                .metadataName,
                        }),
                        isNullable: true,
                        isPrimary,
                    },
                    {
                        name: "value",
                        type: this.connection.driver.normalizeType({
                            type: this.connection.driver.mappedDataTypes
                                .metadataValue,
                        }),
                        isNullable: true,
                        isPrimary,
                    },
                ],
            }),
            true,
        )
    }
}
